Skip to content

[client] auto-update#4732

Merged
pappz merged 143 commits intomainfrom
feat/auto-upgrade-fork
Dec 19, 2025
Merged

[client] auto-update#4732
pappz merged 143 commits intomainfrom
feat/auto-upgrade-fork

Conversation

@pappz
Copy link
Copy Markdown
Collaborator

@pappz pappz commented Oct 31, 2025

Describe your changes

  • Only execute the update when there is human interaction with the "Connect" button in the UI.
  • Run the installation process from a detached binary

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • New Features

    • Background auto-update with per-account/version controls and in-app update progress UI
    • Cross-platform updater/installer with installer result reporting to the daemon and UI
    • New signer CLI for creating, bundling, signing, and verifying artifact keys and revocation lists
  • Documentation

    • Added docs describing updater/installer and signing/verification workflows; embedded verification keys
  • Other

    • Debug bundles now include updater logs; downloader/installer utilities and extensive tests added

✏️ Tip: You can customize this high-level summary in your review settings.

mohamed-essam and others added 30 commits September 1, 2025 15:13
Co-authored-by: Bethuel Mmbaga <bethuelmbaga12@gmail.com>
…rsion-networkmap

Move autoUpdateVersion inside NetworkMap
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (9)
client/internal/updatemanager/installer/installer_common.go (2)

119-121: Consider adding contextual logging for updater process start failure.

While the error is returned, adding a log statement with context (e.g., command path, arguments) would aid troubleshooting when the updater process fails to start.

 	// Start the updater process asynchronously
 	if err := updateCmd.Start(); err != nil {
+		log.Errorf("failed to start updater process %s: %v", updaterPath, err)
 		return err
 	}

127-129: Consider using defer for Process.Release() to guarantee cleanup.

Although there's no error path after Release() in this method, using defer immediately after checking updateCmd.Process would be more robust and follow Go idioms for resource cleanup.

 	pid := updateCmd.Process.Pid
 	log.Infof("updater started with PID %d", pid)

 	// Release the process so the OS can fully detach it
-	if err := updateCmd.Process.Release(); err != nil {
-		log.Warnf("failed to release updater process: %v", err)
-	}
+	defer func() {
+		if err := updateCmd.Process.Release(); err != nil {
+			log.Warnf("failed to release updater process: %v", err)
+		}
+	}()

 	return nil
client/internal/updatemanager/downloader/downloader_test.go (1)

14-199: Downloader tests give solid coverage of happy and error paths

The suite nicely exercises first-try success, retry success/failure, context cancellation during the retry delay, invalid URL, invalid destination, and the retryDelay == 0 no‑retry path using httptest.Server and atomic.Int32. This gives good confidence in DownloadToFile’s behavior.

If you want to trim a bit of repetition, you could optionally factor out helpers for the common TempDir/dstFile setup and the “always 500” server pattern, but it’s not strictly necessary.

client/internal/updatemanager/downloader/downloader.go (3)

100-107: Improve sleepWithContext to avoid dangling time.After timers

Using time.After in a select like this can leave a timer/goroutine around when the context is canceled before the duration elapses. It’s small but avoidable overhead, especially if this is called frequently.

You can use an explicit time.Timer instead so the timer is stopped/drained when the context is done:

-func sleepWithContext(ctx context.Context, duration time.Duration) error {
-	select {
-	case <-time.After(duration):
-		return nil
-	case <-ctx.Done():
-		return ctx.Err()
-	}
-}
+func sleepWithContext(ctx context.Context, duration time.Duration) error {
+	timer := time.NewTimer(duration)
+	defer timer.Stop()
+
+	select {
+	case <-timer.C:
+		return nil
+	case <-ctx.Done():
+		// Ensure the timer channel is drained if it has already fired.
+		if !timer.Stop() {
+			select {
+			case <-timer.C:
+			default:
+			}
+		}
+		return ctx.Err()
+	}
+}

109-137: Clarify/strengthen behavior when response size exceeds limit in DownloadToMemory

Right now, DownloadToMemory uses io.ReadAll(io.LimitReader(resp.Body, limit)). If the server sends more than limit bytes, the reader simply returns EOF at limit, and this function succeeds with truncated data. That may be fine, but it’s somewhat ambiguous: callers can’t tell if they got a full response or a truncated one.

If you intend limit to be a hard upper bound, consider reading limit+1 bytes and failing when the bound is exceeded, e.g.:

-	data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
-	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
-	}
-
-	return data, nil
+	data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response body: %w", err)
+	}
+	if int64(len(data)) > limit {
+		return nil, fmt.Errorf("response too large: exceeds %d bytes", limit)
+	}
+
+	return data, nil

(You might also want to define behavior for limit <= 0, either rejecting it or treating it as “no limit”.)


70-80: Optionally allow a configurable HTTP client instead of hard‑coding http.DefaultClient

Both downloadToFileOnce and DownloadToMemory currently bind directly to http.DefaultClient. That’s fine for now, but you may eventually want to:

  • Set custom timeouts, proxy, or TLS configuration for updater downloads.
  • Inject a custom client in tests (e.g., with lower timeouts or instrumentation).

A lightweight approach is to expose a package‑level client that callers can override in tests or at startup:

 var (
-    // existing imports...
+    // HTTPClient is used for all downloader HTTP requests. It defaults to http.DefaultClient.
+    HTTPClient = http.DefaultClient
 )
 
-	resp, err := http.DefaultClient.Do(req)
+	resp, err := HTTPClient.Do(req)

and similarly in DownloadToMemory. Call sites can then swap in a tuned client when needed.

Also applies to: 109-120

client/ui/update_windows.go (1)

12-44: Clarify semantics of “parent” vs “another” UI process

nbprocess.IsAnotherProcessRunning returns any other UI process with the same name/user, not necessarily a strict parent. The current behavior is fine, but it might be worth updating the comment/log message to reflect that it kills “another running UI instance” to avoid confusion when debugging.

client/ui/event_handler.go (1)

183-275: Log non‑ExitError failures from runSelfCommand

The new variadic runSelfCommand and the --<command>=true argument building look fine, and the updated call sites are consistent. However, if cmd.Run() fails with a non‑*exec.ExitError (e.g., failure to start, context error), the error is currently swallowed.

Consider logging those cases as well:

	if err := cmd.Run(); err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) {
			log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode())
-		}
-		return
+		} else {
+			log.Printf("command '%s' failed: %v", cmd.String(), err)
+		}
+		return
	}
client/ui/update.go (1)

19-99: Confirm treating RPC failures as “success” for closing the UI is intentional

In the installer‑result goroutine, both getSrvClient and GetInstallerResult failures log and then close(resultOkCh). In the watcher goroutine, any resultOkCh signal leads to:

log.Info("backend exited, upgrade in progress, closing all UI")
killParentUIProcess()
s.app.Quit()

This means transient RPC/connectivity errors are indistinguishable from a clean “installer finished” path and will close the UI as if the upgrade completed.

If that’s not strictly intended, consider instead sending a mapped error to resultErrCh for hard failures and reserving resultOkCh for confirmed installer success (or at least for well‑defined “daemon intentionally stopped for upgrade” states).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 717f8bd and 47561f1.

📒 Files selected for processing (9)
  • client/internal/updatemanager/downloader/downloader.go (1 hunks)
  • client/internal/updatemanager/downloader/downloader_test.go (1 hunks)
  • client/internal/updatemanager/installer/installer_common.go (1 hunks)
  • client/internal/updatemanager/reposign/verify.go (1 hunks)
  • client/netbird.wxs (1 hunks)
  • client/ui/event_handler.go (5 hunks)
  • client/ui/update.go (1 hunks)
  • client/ui/update_notwindows.go (1 hunks)
  • client/ui/update_windows.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/internal/updatemanager/reposign/verify.go
🧰 Additional context used
🧬 Code graph analysis (5)
client/ui/update_windows.go (1)
client/ui/process/process.go (1)
  • IsAnotherProcessRunning (11-38)
client/internal/updatemanager/downloader/downloader_test.go (2)
client/internal/updatemanager/downloader/downloader.go (1)
  • DownloadToFile (21-68)
shared/management/status/error.go (1)
  • Errorf (70-75)
client/ui/update.go (2)
client/proto/daemon.pb.go (3)
  • InstallerResultRequest (5367-5371)
  • InstallerResultRequest (5384-5384)
  • InstallerResultRequest (5399-5401)
shared/management/status/error.go (2)
  • Errorf (70-75)
  • Error (54-57)
client/internal/updatemanager/installer/installer_common.go (8)
client/internal/updatemanager/installer/installer.go (3)
  • New (19-21)
  • Installer (14-16)
  • NewWithDir (24-28)
client/internal/updatemanager/installer/result.go (1)
  • NewResultHandler (33-42)
shared/management/status/error.go (2)
  • Errorf (70-75)
  • Error (54-57)
client/internal/updatemanager/reposign/verify.go (1)
  • NewArtifactVerify (34-41)
client/internal/updatemanager/installer/repourl_prod.go (1)
  • DefaultSigningKeysBaseURL (6-6)
client/internal/updatemanager/installer/repourl_dev.go (1)
  • DefaultSigningKeysBaseURL (6-6)
client/internal/updatemanager/installer/types.go (1)
  • Type (3-6)
client/internal/updatemanager/downloader/downloader.go (2)
  • DownloadToFile (21-68)
  • DefaultRetryDelay (18-18)
client/internal/updatemanager/downloader/downloader.go (2)
shared/management/status/error.go (1)
  • Errorf (70-75)
version/version.go (1)
  • NetbirdVersion (18-20)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (26)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Signal / Unit (386)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Android / Build
  • GitHub Check: iOS / Build
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: release_ui_darwin
  • GitHub Check: Client / Unit
  • GitHub Check: release_ui
  • GitHub Check: release
  • GitHub Check: Client / Unit
  • GitHub Check: Linux
  • GitHub Check: Windows
  • GitHub Check: Darwin
🔇 Additional comments (9)
client/internal/updatemanager/installer/installer_common.go (3)

74-83: Signature verification is correctly placed in the service process.

The artifact signature verification occurs before launching the updater binary, which aligns with security best practices. The downloaded installer file is verified in the service process prior to starting the detached updater process.


137-179: LGTM - Previous issues have been addressed.

The nil pointer dereference and error return issues flagged in previous reviews have been properly fixed. The method now correctly handles errors before dereferencing info and properly returns accumulated errors via merr.ErrorOrNil().


181-206: LGTM - Solid download implementation with error cleanup.

The success flag pattern with deferred cleanup ensures the temp directory is properly cleaned up on error. File name validation prevents potential path traversal issues.

client/internal/updatemanager/downloader/downloader.go (1)

16-68: DownloadToFile implementation and retry semantics look correct

DownloadToFile is structured cleanly: it creates the destination file once, ensures it is closed via a defer, performs a single initial attempt, and then (when retryDelay > 0) does a context‑aware sleep, truncates and seeks the file, and performs exactly one retry. Error messages are reasonably specific, and the tests in downloader_test.go line up with this behavior. I don’t see correctness issues here.

client/ui/update_notwindows.go (1)

1-7: Non‑Windows stub looks appropriate

The no‑op killParentUIProcess keeps the API consistent on non‑Windows platforms and matches the existing !(linux && 386) gating; no issues spotted here.

client/ui/update.go (2)

101-125: Installer result handling and dot animation look consistent

showInstallerResult cleanly maps timeout/cancel/other errors into user‑visible messages and unlocks the window, and dotUpdater produces a simple rotating “Updating…” animation. Assuming s.wUpdateProgress is only used from this update flow, the logic here looks solid.


127-140: mapInstallError maps installer messages into useful error types

The normalization and substring checks provide a pragmatic bridge from installer strings to typed errors (DeadlineExceeded, Canceled) while keeping other messages intact; this plays nicely with showInstallerResult’s switch. No issues spotted here.

client/netbird.wxs (1)

53-55: The TerminateProcess="0" value is correct. In WiX v4, TerminateProcess is an integer exit code attribute, not a boolean. Setting it to any decimal integer (including 0) enables forceful process termination and returns that value as the exit code. The code configuration is appropriate: TerminateProcess="0" paired with RebootPrompt="no" correctly enables forced termination of netbird-ui.exe without prompting for reboot.

Likely an incorrect or invalid review comment.

client/ui/event_handler.go (1)

70-97: Confirmed: menuUpClick(..., true) is correctly the only auto‑update trigger

The call sites are properly configured. handleConnectClick explicitly passes true to trigger auto‑update on human "Connect" clicks (event_handler.go:83), while the command-based connection in quickactions.go:270 correctly passes false, preventing unintended auto‑updates from programmatic triggers.

Comment thread client/internal/updatemanager/installer/installer_common.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
client/internal/updatemanager/installer/installer_run_windows.go (2)

18-27: Download URLs point to personal fork repository.

The msiDownloadURL and exeDownloadURL constants reference github.com/mlsmaycon/netbird instead of the official repository. This will cause production builds to fetch installers from a personal fork.

This was flagged in a previous review. Please update these URLs to point to the official repository or package host before merging.


204-213: Architecture mapping may be needed for artifact naming.

The function uses runtime.GOARCH directly without mapping to artifact naming conventions. If the release artifacts use different naming (e.g., x64 instead of amd64), this will cause 404 errors.

This was mentioned in the previous review regarding URL handling. Please verify the artifact naming convention matches runtime.GOARCH values or add appropriate mapping.

🧹 Nitpick comments (7)
client/internal/updatemanager/installer/installer_run_windows.go (2)

173-173: Use named constants for Windows process creation flags.

The magic numbers make the code harder to understand. Consider defining named constants for clarity and maintainability.

+const (
+	createNewProcessGroup     = 0x00000200
+	detachedProcess           = 0x00000008
+	createUnicodeEnvironment  = 0x00000400
+)
+
 func (u *Installer) startUIAsUser(daemonFolder string) error {
 	// ...
-	creationFlags := uint32(0x00000200 | 0x00000008 | 0x00000400) // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_UNICODE_ENVIRONMENT
+	creationFlags := uint32(createNewProcessGroup | detachedProcess | createUnicodeEnvironment)

192-198: Consider using defer for process handle cleanup.

If an error occurs between the two CloseHandle calls (though unlikely given only warnings are logged), the thread handle could leak. Using defer would be more idiomatic and safer.

 	if err != nil {
 		return fmt.Errorf("CreateProcessAsUser failed: %w", err)
 	}

-	// Close handles
-	if err := windows.CloseHandle(pi.Process); err != nil {
-		log.Warnf("failed to close process handle: %v", err)
-	}
-	if err := windows.CloseHandle(pi.Thread); err != nil {
-		log.Warnf("failed to close thread handle: %v", err)
-	}
+	// Close handles - defer in reverse order of importance
+	defer func() {
+		if err := windows.CloseHandle(pi.Thread); err != nil {
+			log.Warnf("failed to close thread handle: %v", err)
+		}
+	}()
+	defer func() {
+		if err := windows.CloseHandle(pi.Process); err != nil {
+			log.Warnf("failed to close process handle: %v", err)
+		}
+	}()
client/internal/updatemanager/installer/installer_common.go (2)

118-128: Process handle release should use defer pattern for safety.

As noted in the PR objectives, calling Process.Release() without defer risks leaked process handles if intermediate errors occur. While unlikely here since it's near the end of the function, using defer is more defensive.

 	// Start the updater process asynchronously
 	if err := updateCmd.Start(); err != nil {
 		return err
 	}

 	pid := updateCmd.Process.Pid
 	log.Infof("updater started with PID %d", pid)

-	// Release the process so the OS can fully detach it
-	if err := updateCmd.Process.Release(); err != nil {
-		log.Warnf("failed to release updater process: %v", err)
-	}
+	// Release the process so the OS can fully detach it
+	defer func() {
+		if err := updateCmd.Process.Release(); err != nil {
+			log.Warnf("failed to release updater process: %v", err)
+		}
+	}()

 	return nil

156-159: Early return discards accumulated errors.

If os.ReadDir fails, the error from removing the updater binary (accumulated in merr at line 152-154) is lost. Consider combining the errors.

 	entries, err := os.ReadDir(u.tempDir)
 	if err != nil {
-		return err
+		merr = multierror.Append(merr, fmt.Errorf("failed to read temp dir: %w", err))
+		return merr.ErrorOrNil()
 	}
client/internal/updatemanager/downloader/downloader.go (3)

21-68: Retry flow is good; consider context-aware early exit and atomic temp-file writes

The overall retry logic and error wrapping look solid. Two small robustness tweaks you might consider:

  • If the first attempt fails due to context.Canceled / DeadlineExceeded, you can skip logging “retrying” and skip the retry entirely to avoid misleading logs and unnecessary work.
  • For update artifacts, an atomic “download to dstFile.tmp then os.Rename to dstFile” pattern prevents other readers from ever seeing a partially written file.

Example for the context-aware early exit:

-import (
-	"context"
-	"fmt"
+import (
+	"context"
+	"errors"
+	"fmt"
@@
-	// First attempt
-	err = downloadToFileOnce(ctx, url, out)
-	if err == nil {
+	// First attempt
+	err = downloadToFileOnce(ctx, url, out)
+	if err == nil {
 		log.Infof("successfully downloaded file to %s", dstFile)
 		return nil
 	}
 
-	// If retryDelay is 0, don't retry
-	if retryDelay == 0 {
+	// If retryDelay is 0 or context is already done, don't retry
+	if retryDelay == 0 ||
+		errors.Is(err, context.Canceled) ||
+		errors.Is(err, context.DeadlineExceeded) {
 		return err
 	}

70-99: Clarify limit semantics and behavior on oversized responses

DownloadToMemory works, but two edge cases are a bit underspecified:

  • If limit <= 0, io.LimitReader yields an immediate EOF and you successfully return an empty slice, which can easily mask misconfiguration at call sites.
  • If the server sends more than limit bytes, you’ll silently truncate to limit bytes without signaling that the payload was incomplete.

For an updater path, both can be surprising. Consider:

  • Either rejecting limit <= 0 (or treating it as “no limit”) explicitly.
  • Optionally, failing fast when resp.ContentLength > 0 && resp.ContentLength > limit, so callers get a clear “response too large” error instead of truncated data.

Example of a defensive guard at the top of the function:

func DownloadToMemory(ctx context.Context, url string, limit int64) ([]byte, error) {
+	if limit <= 0 {
+		return nil, fmt.Errorf("limit must be greater than 0, got %d", limit)
+	}

101-138: Helpers correctly handle context and resource cleanup

downloadToFileOnce and sleepWithContext look clean: good use of NewRequestWithContext, proper defer for resp.Body.Close(), and straightforward status checks and copy logic. Using sleepWithContext to make the retry delay cancellable is also a nice touch.

One possible future improvement would be to accept an *http.Client as a dependency (rather than using http.DefaultClient) so callers can inject timeouts/proxy settings specific to the update channel, but that’s not strictly necessary for this PR.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 47561f1 and 306596b.

📒 Files selected for processing (3)
  • client/internal/updatemanager/downloader/downloader.go (1 hunks)
  • client/internal/updatemanager/installer/installer_common.go (1 hunks)
  • client/internal/updatemanager/installer/installer_run_windows.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
client/internal/updatemanager/downloader/downloader.go (1)
version/version.go (1)
  • NetbirdVersion (18-20)
client/internal/updatemanager/installer/installer_run_windows.go (6)
client/internal/updatemanager/installer/installer_common.go (1)
  • Installer (23-25)
client/internal/updatemanager/installer/installer.go (1)
  • Installer (14-16)
client/internal/updatemanager/installer/result.go (1)
  • NewResultHandler (33-42)
shared/management/status/error.go (1)
  • Errorf (70-75)
client/internal/updatemanager/installer/types_windows.go (1)
  • TypeExe (18-18)
client/internal/updatemanager/installer/types.go (1)
  • Type (3-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (28)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Management / Integration (amd64, sqlite)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Signal / Unit (amd64)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Signal / Unit (386)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: release_ui
  • GitHub Check: release_ui_darwin
  • GitHub Check: release
  • GitHub Check: Android / Build
  • GitHub Check: iOS / Build
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: Linux
  • GitHub Check: Windows
  • GitHub Check: Darwin
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
🔇 Additional comments (10)
client/internal/updatemanager/installer/installer_run_windows.go (3)

36-104: LGTM - Setup method has solid error handling and cleanup.

The deferred cleanup block ensures daemon and UI are always restarted regardless of installation outcome, and results are properly persisted. The installer type detection and command construction are well-structured.


106-118: LGTM - Service start helper is well-implemented.

The 15-second timeout is reasonable, and capturing combined output aids debugging.


120-170: LGTM - Token handling and process creation are correct.

Token cleanup properly uses defer, and the command line path is now correctly quoted (addressing the previous review feedback).

client/internal/updatemanager/installer/installer_common.go (7)

23-39: LGTM - Installer struct and constructors are clean.

Simple struct with appropriate constructors for different use cases (service vs updater process).


73-82: Artifact signature verification correctly occurs before updater launch.

The signature verification via artifactVerify.Verify() happens in the service process before copyUpdater() and process launch, addressing the PR objective concern about verification timing.


180-205: LGTM - Download helper with proper cleanup on failure.

Using path.Base for URL parsing and validating the extracted filename is correct. The aggressive cleanup of the entire temp directory on failure ensures no partial state is left behind.


250-277: LGTM - File copy implementation is correct.

The deferred close handlers properly identify source vs destination files in log messages, and error propagation is handled correctly.


237-248: LGTM - Version validation using semantic versioning library.

Using go-version for validation ensures proper semver compliance and provides clear error messages for invalid versions.


279-293: LGTM - Service path resolution and dry-run detection.

The utilities correctly resolve paths relative to the running executable and handle environment variable parsing robustly.


44-70: Critical: Variable shadowing prevents deferred error writing from working.

The deferred function at lines 46-52 captures the named return err, but most error checks use if err := which creates a new shadowed variable. When those shadowed errors are returned, the deferred function still sees the outer err as nil, so error results are never written.

For example, line 54 if err := validateTargetVersion(...) creates a new err that is returned but the deferred function won't see it.

Either remove the shadowing by assigning to the outer variable:

-	if err := validateTargetVersion(targetVersion); err != nil {
-		return err
+	if err = validateTargetVersion(targetVersion); err != nil {
+		return
 	}

-	if err := u.mkTempDir(); err != nil {
-		return err
+	if err = u.mkTempDir(); err != nil {
+		return
 	}

Or restructure the deferred function to use the return value directly (less idiomatic).

Likely an incorrect or invalid review comment.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
client/internal/profilemanager/config.go (2)

165-180: Config dir permissions look too open (0o755) for sensitive client material; use 0o700 and wrap the error.
If this directory contains configs/keys, 0o755 allows other local users to list contents. Also, returning a wrapped error helps diagnostics.

 	configDir := filepath.Join(base, "netbird")
-	if err := os.MkdirAll(configDir, 0o755); err != nil {
-		return "", err
+	if err := os.MkdirAll(configDir, 0o700); err != nil {
+		return "", fmt.Errorf("create config dir %q: %w", configDir, err)
 	}
 	return configDir, nil
 }

191-206: Directory creation mode 0600 is invalid on Unix directories (no execute bit) → will break traversal.
This should be at least 0700 (or your standard policy).

 	configDir := filepath.Join(DefaultConfigPathDir, username)
 	if _, err := os.Stat(configDir); os.IsNotExist(err) {
-		if err := os.MkdirAll(configDir, 0600); err != nil {
+		if err := os.MkdirAll(configDir, 0o700); err != nil {
 			return "", err
 		}
 	}
♻️ Duplicate comments (1)
client/internal/updatemanager/installer/installer_run_darwin.go (1)

18-26: Avoid hardcoded temp dir + fork download URL; Type arg is ignored
These were already raised before: defaultTempDir is hardcoded, pkgDownloadURL points at a personal fork, and urlWithVersionArch(_ Type, ...) ignores Type (confusing API surface).

Also applies to: 235-238

🧹 Nitpick comments (2)
client/internal/profilemanager/config.go (1)

3-15: Darwin home resolution: os/user may be brittle depending on build mode—consider os.UserHomeDir() or document why user.Current() is required.
On some setups (notably certain cross-compile/static builds), os/user behavior can be surprising; if you rely on user.Current() to work without $HOME, that’s worth a short comment explaining the rationale.

 import (
 	"context"
 	"crypto/tls"
 	"fmt"
 	"net/url"
 	"os"
-	"os/user"
 	"path/filepath"
 	"reflect"
 	"runtime"
 	"slices"
 	"strings"
 	"time
@@
 )
 func baseConfigDir() (string, error) {
 	if runtime.GOOS == "darwin" {
-		if u, err := user.Current(); err == nil && u.HomeDir != "" {
-			return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
+		if home, err := os.UserHomeDir(); err == nil && home != "" {
+			return filepath.Join(home, "Library", "Application Support"), nil
 		}
 	}
 	return os.UserConfigDir()
 }
client/internal/updatemanager/installer/installer_run_darwin.go (1)

142-161: PKG install drops stdout/stderr; add captured output for debugging failures
Right now cmd.Wait() errors don’t include installer output, which will make field debugging painful. You can keep Start/Wait (for your “don’t double-start” constraint) but still capture logs via buffers.

 func (u *Installer) installPkgFile(ctx context.Context, path string) error {
 	log.Infof("installing pkg file: %s", path)
@@
 	cmd := exec.CommandContext(ctx, "installer", "-pkg", path, "-target", volume)
+	var out strings.Builder
+	cmd.Stdout = &out
+	cmd.Stderr = &out
 	if err := cmd.Start(); err != nil {
 		return fmt.Errorf("error running pkg file: %w", err)
 	}
 	log.Infof("installer started with PID %d", cmd.Process.Pid)
 	if err := cmd.Wait(); err != nil {
-		return fmt.Errorf("error running pkg file: %w", err)
+		return fmt.Errorf("error running pkg file: %w, output: %s", err, out.String())
 	}
 	log.Infof("pkg file installed successfully")
 	return nil
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 306596b and 0642e97.

📒 Files selected for processing (2)
  • client/internal/profilemanager/config.go (2 hunks)
  • client/internal/updatemanager/installer/installer_run_darwin.go (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Darwin
  • GitHub Check: Windows
  • GitHub Check: Build Cache
  • GitHub Check: Linux
  • GitHub Check: release_ui
  • GitHub Check: release_ui_darwin
  • GitHub Check: Client / Unit
  • GitHub Check: release
  • GitHub Check: iOS / Build
  • GitHub Check: Android / Build
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: Client / Unit
🔇 Additional comments (2)
client/internal/profilemanager/config.go (1)

182-189: baseConfigDir() logic is reasonable; please add a small comment about why Darwin is special-cased.
Right now it’s not obvious why os.UserConfigDir() isn’t sufficient on macOS; a one-liner would prevent future “simplification” regressions.

client/internal/updatemanager/installer/installer_run_darwin.go (1)

224-233: pkill handling looks fine
Treating exit code 1 as “no processes matched” is the right behavior here.

Comment on lines +32 to +82
// Setup runs the installer with appropriate arguments and manages the daemon/UI state
// This will be run by the updater process
func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) {
resultHandler := NewResultHandler(u.tempDir)

// Always ensure daemon and UI are restarted after setup
defer func() {
log.Infof("write out result")
var err error
if resultErr == nil {
err = resultHandler.WriteSuccess()
} else {
err = resultHandler.WriteErr(resultErr)
}
if err != nil {
log.Errorf("failed to write update result: %v", err)
}

// skip service restart if dry-run mode is enabled
if dryRun {
return
}

log.Infof("starting daemon back")
if err := u.startDaemon(daemonFolder); err != nil {
log.Errorf("failed to start daemon: %v", err)
}

log.Infof("starting UI back")
if err := u.startUIAsUser(); err != nil {
log.Errorf("failed to start UI: %v", err)
}

}()

if dryRun {
time.Sleep(7 * time.Second)
log.Infof("dry-run mode enabled, skipping actual installation")
resultErr = fmt.Errorf("dry-run mode enabled")
return
}

switch typeOfInstaller(ctx) {
case TypePKG:
resultErr = u.installPkgFile(ctx, installerFile)
case TypeHomebrew:
resultErr = u.updateHomeBrew(ctx)
}

return resultErr
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Setup can report success without performing any install (missing default in switch)
If typeOfInstaller(ctx) returns an unexpected value, resultErr stays nil and you’ll write a success result + restart components even though nothing ran. Also, TypePKG should validate installerFile != "".

 func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) {
@@
-	switch typeOfInstaller(ctx) {
+	switch typeOfInstaller(ctx) {
 	case TypePKG:
+		if strings.TrimSpace(installerFile) == "" {
+			resultErr = fmt.Errorf("installer file path is empty")
+			return resultErr
+		}
 		resultErr = u.installPkgFile(ctx, installerFile)
 	case TypeHomebrew:
 		resultErr = u.updateHomeBrew(ctx)
+	default:
+		resultErr = fmt.Errorf("unsupported installer type")
 	}
 
 	return resultErr
 }
🤖 Prompt for AI Agents
In client/internal/updatemanager/installer/installer_run_darwin.go around lines
32 to 82, the switch on typeOfInstaller can fall through leaving resultErr nil
(causing a false success) and TypePKG doesn’t validate installerFile; add a
default case that sets resultErr to a clear error like fmt.Errorf("unsupported
installer type: %v", typeOfInstaller(ctx)) so unknown types fail, and before
calling u.installPkgFile validate installerFile != "" and set resultErr
accordingly (e.g., fmt.Errorf("installer file required for PKG installer")) to
avoid attempting an install with an empty path.

Comment on lines +67 to +72
if dryRun {
time.Sleep(7 * time.Second)
log.Infof("dry-run mode enabled, skipping actual installation")
resultErr = fmt.Errorf("dry-run mode enabled")
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find the full context of this function
cat -n client/internal/updatemanager/installer/installer_run_darwin.go | head -100

Repository: netbirdio/netbird

Length of output: 3181


🏁 Script executed:

# Search for the function signature and where resultErr is returned
rg -A 20 -B 5 "func.*installer_run_darwin" client/internal/updatemanager/installer/

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Search for how this function is called and how the error is handled
rg "resultErr\|dry-run mode enabled" client/internal/updatemanager/

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Look for similar dry-run error handling patterns in the codebase
rg -B 3 -A 3 "dry.?run\|dryRun" client/internal/updatemanager/ -i

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Search for calls to Setup method
rg "\.Setup\(" client/internal/updatemanager/ -B 2 -A 5

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Look for the ResultHandler implementation and WriteErr method
fd "result.*handler\|handler.*result" client/internal/updatemanager/

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Search more broadly for Setup calls in the entire repository
rg "Setup\(" --type go -B 2 -A 5 | head -100

Repository: netbirdio/netbird

Length of output: 4329


🏁 Script executed:

# Look for WriteErr and WriteSuccess implementations
rg -B 5 -A 10 "WriteErr\|WriteSuccess" client/internal/updatemanager/

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Check Windows implementation for dry-run handling
sed -n '67,80p' client/internal/updatemanager/installer/installer_run_windows.go

Repository: netbirdio/netbird

Length of output: 310


🏁 Script executed:

# Search for any special handling or checking of "dry-run mode enabled" error
rg "dry-run mode enabled\|dryRun\|dry.?run" client/cmd/ -B 3 -A 3

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Check if there's any error comparison or type checking for dry-run errors
rg "errors\.Is\|errors\.As\|strings\.Contains" client/cmd/update_supported.go -B 2 -A 2

Repository: netbirdio/netbird

Length of output: 43


🏁 Script executed:

# Check the full update_supported.go file to see the complete error handling flow
cat -n client/cmd/update_supported.go | grep -A 20 -B 5 "Setup"

Repository: netbirdio/netbird

Length of output: 834


Dry-run mode should not persist as a failure result

The dryRun branch sets resultErr = fmt.Errorf("dry-run mode enabled"), which is written as an error result via resultHandler.WriteErr() in the defer block (lines 38-45). Upstream callers in client/cmd/update_supported.go (line 58) treat this identically to real installation failures, logging "failed to update application" and returning the error. This could trigger false alarms in monitoring and automation systems. Return nil for successful dry-run execution instead, or introduce a dedicated sentinel error that callers can explicitly distinguish and ignore.

🤖 Prompt for AI Agents
In client/internal/updatemanager/installer/installer_run_darwin.go around lines
67 to 72, the dry-run branch currently sets resultErr to an error which
downstream handlers treat as a real failure; change this so dry-run does not
persist as a failure by setting resultErr = nil (or alternatively return a
documented sentinel error that upstream explicitly ignores), and ensure the
function returns immediately after setting resultErr to nil so the defer
resultHandler.WriteErr() records no error for dry-run runs.

Comment on lines +98 to +140
func (u *Installer) startUIAsUser() error {
log.Infof("starting netbird-ui: %s", uiBinary)

// Get the current console user
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get console user: %w", err)
}

username := strings.TrimSpace(string(output))
if username == "" || username == "root" {
return fmt.Errorf("no active user session found")
}

log.Infof("starting UI for user: %s", username)

// Get user's UID
userInfo, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("failed to lookup user %s: %w", username, err)
}

// Start the UI process as the console user using launchctl
// This ensures the app runs in the user's context with proper GUI access
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
log.Infof("launchCmd: %s", launchCmd.String())
// Set the user's home directory for proper macOS app behavior
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)

if err := launchCmd.Start(); err != nil {
return fmt.Errorf("failed to start UI process: %w", err)
}

// Release the process so it can run independently
if err := launchCmd.Process.Release(); err != nil {
log.Warnf("failed to release UI process: %v", err)
}

log.Infof("netbird-ui started successfully for user %s", username)
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potentially sensitive logs: username + HOME directory
startUIAsUser logs the console username and HOME path; if logs are uploaded, that’s unnecessary PII/host info. Recommend downgrading/removing the HOME-path log line and avoiding logging usernames at Info.

🤖 Prompt for AI Agents
In client/internal/updatemanager/installer/installer_run_darwin.go around lines
98 to 140, the function logs the console username and the user's HOME path which
are PII; remove or downgrade these logs. Replace the Info-level logs that print
username with Debug-level or redact the value (or omit it entirely), and delete
the log line that prints HOME (do not log userInfo.HomeDir). Keep setting HOME
in the launchCmd.Env as-is, and update the final success log to a non-PII
message such as "netbird-ui started for active user session" or a Debug log if
you want more detail.

Comment on lines +163 to +222
func (u *Installer) updateHomeBrew(ctx context.Context) error {
log.Infof("updating homebrew")

// Kill any existing UI processes before upgrade
// This ensures the new version will be started after upgrade
u.killUI()

// Homebrew must be run as a non-root user
// To find out which user installed NetBird using HomeBrew we can check the owner of our brew tap directory
// Check both Apple Silicon and Intel Mac paths
brewTapPath := "/opt/homebrew/Library/Taps/netbirdio/homebrew-tap/"
brewBinPath := "/opt/homebrew/bin/brew"
if _, err := os.Stat(brewTapPath); os.IsNotExist(err) {
// Try Intel Mac path
brewTapPath = "/usr/local/Homebrew/Library/Taps/netbirdio/homebrew-tap/"
brewBinPath = "/usr/local/bin/brew"
}

fileInfo, err := os.Stat(brewTapPath)
if err != nil {
return fmt.Errorf("error getting homebrew installation path info: %w", err)
}

fileSysInfo, ok := fileInfo.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("error checking file owner, sysInfo type is %T not *syscall.Stat_t", fileInfo.Sys())
}

// Get username from UID
brewUser, err := user.LookupId(fmt.Sprintf("%d", fileSysInfo.Uid))
if err != nil {
return fmt.Errorf("error looking up brew installer user: %w", err)
}
userName := brewUser.Username
// Get user HOME, required for brew to run correctly
// https://github.com/Homebrew/brew/issues/15833
homeDir := brewUser.HomeDir

// Check if netbird-ui is installed (must run as the brew user, not root)
checkUICmd := exec.CommandContext(ctx, "sudo", "-u", userName, brewBinPath, "list", "--formula", "netbirdio/tap/netbird-ui")
checkUICmd.Env = append(os.Environ(), "HOME="+homeDir)
uiInstalled := checkUICmd.Run() == nil

// Homebrew does not support installing specific versions
// Thus it will always update to latest and ignore targetVersion
upgradeArgs := []string{"-u", userName, brewBinPath, "upgrade", "netbirdio/tap/netbird"}
if uiInstalled {
upgradeArgs = append(upgradeArgs, "netbirdio/tap/netbird-ui")
}

cmd := exec.CommandContext(ctx, "sudo", upgradeArgs...)
cmd.Env = append(os.Environ(), "HOME="+homeDir)

if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("error running brew upgrade: %w, output: %s", err, string(output))
}

log.Infof("homebrew updated successfully")
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Homebrew path: guard against root owner + verify brew binary exists
If the tap directory owner resolves to root, this will attempt to run brew as root (which Homebrew explicitly discourages). Also, you should fail fast if brewBinPath doesn’t exist even when the tap path does.

 	if _, err := os.Stat(brewTapPath); os.IsNotExist(err) {
 		// Try Intel Mac path
 		brewTapPath = "/usr/local/Homebrew/Library/Taps/netbirdio/homebrew-tap/"
 		brewBinPath = "/usr/local/bin/brew"
 	}
+	if _, err := os.Stat(brewBinPath); err != nil {
+		return fmt.Errorf("homebrew binary not found at %s: %w", brewBinPath, err)
+	}
@@
 	userName := brewUser.Username
+	if userName == "root" || userName == "" {
+		return fmt.Errorf("refusing to run Homebrew as user %q", userName)
+	}
🤖 Prompt for AI Agents
In client/internal/updatemanager/installer/installer_run_darwin.go around lines
163 to 222, add a guard to prevent running Homebrew as root and fail fast if the
brew binary is missing: after resolving fileSysInfo and before looking up the
user, check if fileSysInfo.Uid == 0 and return a clear error instructing that
Homebrew must not be run as root (or that the tap appears owned by root and
needs fixing); after determining brewBinPath, verify the binary exists (os.Stat)
and return an error if it does not, so we don't attempt to run a non-existent
brew command. Ensure errors are descriptive and returned early.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
client/internal/updatemanager/manager.go (2)

77-112: Don’t delete persisted update state before emitting the “completed” event (crash window).

Current flow deletes the only evidence (UpdateState) before you decide whether to publish the “Auto-update completed” event. A crash between delete and publish loses the completion notification permanently.

Concrete fix: split “load” from “delete”, publish first, delete after. (This is the same concern raised previously, but it’s still present in this version.)

 func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
@@
-	updateState, err := m.loadAndDeleteUpdateState(ctx)
+	updateState, err := m.loadUpdateState()
 	if err != nil {
 		if errors.Is(err, errNoUpdateState) {
 			return
 		}
 		log.Errorf("failed to load update state: %v", err)
 		return
 	}
@@
 	if updateState.TargetVersion == m.currentVersion {
 		m.statusRecorder.PublishEvent(
@@
 		)
-		return
 	}
+
+	// Best-effort cleanup after emitting the event decision.
+	if err := m.deleteUpdateState(ctx, updateState); err != nil {
+		log.Errorf("failed to delete update state: %v", err)
+	}
 }
+
+func (m *Manager) loadUpdateState() (*UpdateState, error) {
+	stateType := &UpdateState{}
+	if err := m.stateManager.LoadState(stateType); err != nil {
+		return nil, fmt.Errorf("load state: %w", err)
+	}
+	state := m.stateManager.GetState(stateType)
+	if state == nil {
+		return nil, errNoUpdateState
+	}
+	updateState, ok := state.(*UpdateState)
+	if !ok {
+		return nil, fmt.Errorf("failed to cast state to UpdateState")
+	}
+	return updateState, nil
+}
+
+func (m *Manager) deleteUpdateState(ctx context.Context, updateState *UpdateState) error {
+	if err := m.stateManager.DeleteState(updateState); err != nil {
+		return fmt.Errorf("delete state: %w", err)
+	}
+	return m.stateManager.PersistState(ctx)
+}

Also applies to: 91-111, 306-335


114-135: Lifecycle (Start/Stop) isn’t concurrency-safe; at least document or guard m.cancel access.

Start()/Stop()/onContextCancel() use m.cancel as a sentinel without any synchronization, so concurrent callers can race (and this was already pointed out in prior review comments). If the design relies on “upper layer synchronization”, it’s worth making that explicit at the type level (godoc) or adding a small mutex around lifecycle transitions to prevent footguns.

Also applies to: 175-203

🧹 Nitpick comments (2)
client/internal/updatemanager/manager.go (2)

61-75: Avoid re-registering UpdateState on every load; validate nil inputs in NewManager.

NewManager() already registers UpdateState, but loadAndDeleteUpdateState() registers again. If RegisterState isn’t strictly idempotent, this can become a subtle bug. Also, statusRecorder / stateManager nil will panic later.

 func NewManager(statusRecorder *peer.Status, stateManager *statemanager.Manager) (*Manager, error) {
+    if statusRecorder == nil {
+        return nil, fmt.Errorf("statusRecorder is nil")
+    }
+    if stateManager == nil {
+        return nil, fmt.Errorf("stateManager is nil")
+    }
 	manager := &Manager{
 		statusRecorder: statusRecorder,
 		stateManager:   stateManager,
@@
 	}
 	manager.triggerUpdateFn = manager.triggerUpdate
 
 	stateManager.RegisterState(&UpdateState{})
 
 	return manager, nil
 }
@@
 func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, error) {
 	stateType := &UpdateState{}
 
-	m.stateManager.RegisterState(stateType)
 	if err := m.stateManager.LoadState(stateType); err != nil {
 		return nil, fmt.Errorf("load state: %w", err)
 	}

Also applies to: 72-73, 308-334


352-355: Replace magic 5*time.Minute with a named constant.

Keeps behavior identical, but makes policy easier to tune/grep (and aligns with other “update policy” knobs).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0642e97 and e85be24.

📒 Files selected for processing (1)
  • client/internal/updatemanager/manager.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
client/internal/updatemanager/manager.go (7)
client/internal/updatemanager/installer/installer_common.go (1)
  • New (28-32)
client/internal/updatemanager/installer/installer.go (1)
  • New (19-21)
client/internal/updatemanager/update.go (1)
  • UpdateInterface (5-11)
version/version.go (1)
  • NetbirdVersion (18-20)
version/update.go (1)
  • NewUpdate (38-51)
shared/management/status/error.go (1)
  • Errorf (70-75)
client/internal/updatemanager/installer/result.go (1)
  • NewResultHandler (33-42)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (26)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Signal / Unit (amd64)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Management / Integration (amd64, sqlite)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Android / Build
  • GitHub Check: Darwin
  • GitHub Check: Windows
  • GitHub Check: JS / Lint
  • GitHub Check: Linux
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: release_ui
  • GitHub Check: Client / Unit
  • GitHub Check: release
  • GitHub Check: release_ui_darwin
🔇 Additional comments (1)
client/internal/updatemanager/manager.go (1)

221-234: Verify thread-safety of m.update.LatestVersion() while fetcher runs (possible data race).

StartFetcher() runs in its own goroutine, and handleUpdate() calls m.update.LatestVersion() outside updateMutex. If the version.Update implementation isn’t internally synchronized, this can race. Consider either (a) documenting that UpdateInterface methods are goroutine-safe, or (b) reading LatestVersion() under updateMutex (or adding a dedicated RW lock just for update access).

Also applies to: 232-233

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (7)
client/internal/updatemanager/installer/types_darwin.go (1)

13-22: Guard against nil ProcessState panic.

The existing past review comment on this issue remains valid. Line 16 dereferences cmd.ProcessState without checking if it's nil, which can panic if cmd.Output() fails before the process starts (e.g., pkgutil is missing or not executable).

Apply this diff to use safe error type checking:

 func TypeOfInstaller(ctx context.Context) Type {
 	cmd := exec.CommandContext(ctx, "pkgutil", "--pkg-info", "io.netbird.client")
 	_, err := cmd.Output()
-	if err != nil && cmd.ProcessState.ExitCode() == 1 {
-		// Not installed using pkg file, thus installed using Homebrew
-
+	if err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+			// Not installed using pkg file, thus installed using Homebrew
+			return TypeHomebrew
+		}
+		// For other errors (ctx cancellation, missing pkgutil, etc.), fall back to PKG
 		return TypeHomebrew
 	}
 	return TypePKG
 }
client/internal/updatemanager/installer/installer_run_darwin.go (5)

25-25: Hard-coded personal fork URL is a supply-chain risk.

The previous review comment on this line remains unresolved. The download URL points to github.com/mlsmaycon/netbird (personal fork) instead of the official repository. This is a critical supply-chain and reliability risk for production releases.

Change pkgDownloadURL to point to the canonical NetBird organization repository or make it configurable via a build-time constant or environment variable.


74-79: Add default case to prevent silent success on unknown installer type.

The previous review comment on this section remains valid. The switch statement on TypeOfInstaller(ctx) lacks a default case. If an unexpected installer type is returned, resultErr remains nil and the function reports success despite performing no installation.

Apply this diff:

 	switch TypeOfInstaller(ctx) {
 	case TypePKG:
+		if strings.TrimSpace(installerFile) == "" {
+			resultErr = fmt.Errorf("installer file path is empty for PKG installer")
+			return resultErr
+		}
 		resultErr = u.installPkgFile(ctx, installerFile)
 	case TypeHomebrew:
 		resultErr = u.updateHomeBrew(ctx)
+	default:
+		resultErr = fmt.Errorf("unsupported installer type: %v", TypeOfInstaller(ctx))
 	}

67-72: Dry-run should not persist as a failure result.

The previous review comment on this section is still applicable. Setting resultErr = fmt.Errorf("dry-run mode enabled") causes the deferred result handler to write this as a failure (line 44). Upstream callers treat this identically to real installation failures, which can trigger false alarms in monitoring systems.

For successful dry-run execution, set resultErr = nil and return immediately:

 	if dryRun {
 		time.Sleep(7 * time.Second)
 		log.Infof("dry-run mode enabled, skipping actual installation")
-		resultErr = fmt.Errorf("dry-run mode enabled")
-		return
+		return nil
 	}

113-113: Avoid logging PII (username and HOME directory).

The previous review comment regarding PII in logs remains valid. Lines 113, 127, and 138 log the console username and HOME directory path. If logs are collected or uploaded, this exposes unnecessary personally identifiable information.

Downgrade these logs to Debug level or redact the sensitive values:

-	log.Infof("starting UI for user: %s", username)
+	log.Debugf("starting UI for console user")
-	log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
+	log.Debugf("set HOME environment variable for user session")
-	log.Infof("netbird-ui started successfully for user %s", username)
+	log.Infof("netbird-ui started successfully")

Also applies to: 127-127, 138-138


163-222: Add safeguards for Homebrew execution.

The previous review comment on this section remains applicable. Two issues:

  1. No root user guard: If the tap directory owner resolves to root (line 192-196), the code will attempt to run Homebrew as root, which Homebrew explicitly discourages and may fail.
  2. Missing brew binary check: The code should fail fast if brewBinPath doesn't exist (lines 174, 178).

Apply this diff:

 	if _, err := os.Stat(brewTapPath); os.IsNotExist(err) {
 		// Try Intel Mac path
 		brewTapPath = "/usr/local/Homebrew/Library/Taps/netbirdio/homebrew-tap/"
 		brewBinPath = "/usr/local/bin/brew"
 	}
+
+	// Verify brew binary exists
+	if _, err := os.Stat(brewBinPath); err != nil {
+		return fmt.Errorf("homebrew binary not found at %s: %w", brewBinPath, err)
+	}

 	fileInfo, err := os.Stat(brewTapPath)
 	if err != nil {
 		return fmt.Errorf("error getting homebrew installation path info: %w", err)
 	}

 	fileSysInfo, ok := fileInfo.Sys().(*syscall.Stat_t)
 	if !ok {
 		return fmt.Errorf("error checking file owner, sysInfo type is %T not *syscall.Stat_t", fileInfo.Sys())
 	}

 	// Get username from UID
 	brewUser, err := user.LookupId(fmt.Sprintf("%d", fileSysInfo.Uid))
 	if err != nil {
 		return fmt.Errorf("error looking up brew installer user: %w", err)
 	}
 	userName := brewUser.Username
+	
+	// Refuse to run as root
+	if userName == "root" || userName == "" {
+		return fmt.Errorf("refusing to run Homebrew as user %q (Homebrew should not be run as root)", userName)
+	}
client/internal/updatemanager/manager.go (1)

184-198: Potential race condition in Stop() method.

Line 185 reads m.cancel outside the mutex, then line 189 calls m.cancel(), then lines 190-195 lock the mutex to access m.update. If Stop() is called concurrently with another method that modifies m.cancel or m.update, this can lead to race conditions or nil pointer dereferences.

Consider holding the lock for the entire Stop() operation:

 func (m *Manager) Stop() {
+	m.updateMutex.Lock()
+	defer m.updateMutex.Unlock()
+
 	if m.cancel == nil {
 		return
 	}

 	m.cancel()
-	m.updateMutex.Lock()
 	if m.update != nil {
 		m.update.StopWatch()
 		m.update = nil
 	}
-	m.updateMutex.Unlock()

+	// Release lock before waiting for goroutines
+	m.updateMutex.Unlock()
 	m.wg.Wait()
+	m.updateMutex.Lock()
 }

Alternatively, document that Stop() must not be called concurrently and add a note that upper-layer synchronization is required (as mentioned in past review responses).

🧹 Nitpick comments (3)
client/internal/updatemanager/installer/installer_common.go (1)

211-217: Harden temp directory permissions.

Line 212 creates the temp directory with 0o755 permissions, making it world-readable. Since the updater binary is copied here and later executed with elevated privileges, consider using stricter permissions (0o700) to prevent unauthorized access or tampering.

Apply this diff:

 func (u *Installer) mkTempDir() error {
-	if err := os.MkdirAll(u.tempDir, 0o755); err != nil {
+	if err := os.MkdirAll(u.tempDir, 0o700); err != nil {
 		log.Debugf("failed to create tempdir: %s", u.tempDir)
 		return err
 	}
client/internal/updatemanager/installer/installer_run_darwin.go (1)

235-238: Remove unused Type parameter or incorporate it into URL logic.

The urlWithVersionArch function accepts a Type parameter but ignores it (line 235). Either use it to select different download channels/paths, or remove it to avoid confusion.

Apply this diff to remove the unused parameter:

-func urlWithVersionArch(_ Type, version string) string {
+func urlWithVersionArch(version string) string {
 	url := strings.ReplaceAll(pkgDownloadURL, "%version", version)
 	return strings.ReplaceAll(url, "%arch", runtime.GOARCH)
 }

Then update the caller in installer_common.go (line 181) accordingly.

client/internal/updatemanager/manager.go (1)

354-357: Magic number should be a named constant.

The 5-minute throttle interval (line 354) is hard-coded. Consider defining it as a package-level constant for easier maintenance and testing.

Apply this diff:

+const (
+	minUpdateInterval = 5 * time.Minute
+)
+
 func (m *Manager) shouldUpdate(updateVersion *v.Version) bool {
 	// ... existing checks ...
 
-	if time.Since(m.lastTrigger) < 5*time.Minute {
+	if time.Since(m.lastTrigger) < minUpdateInterval {
 		log.Debugf("skipping auto-update, last update was %s ago", time.Since(m.lastTrigger))
 		return false
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e85be24 and ebb8cf8.

📒 Files selected for processing (6)
  • client/internal/updatemanager/installer/installer_common.go (1 hunks)
  • client/internal/updatemanager/installer/installer_run_darwin.go (1 hunks)
  • client/internal/updatemanager/installer/types_darwin.go (1 hunks)
  • client/internal/updatemanager/installer/types_windows.go (1 hunks)
  • client/internal/updatemanager/manager.go (1 hunks)
  • management/server/types/settings.go (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • management/server/types/settings.go
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-13T00:29:53.247Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.247Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/internal/updatemanager/installer/installer_run_darwin.go
🧬 Code graph analysis (3)
client/internal/updatemanager/installer/installer_run_darwin.go (5)
client/internal/updatemanager/installer/installer_common.go (1)
  • Installer (23-25)
client/internal/updatemanager/installer/installer.go (1)
  • Installer (14-16)
client/internal/updatemanager/installer/result.go (1)
  • NewResultHandler (33-42)
client/internal/updatemanager/installer/types_darwin.go (3)
  • TypeOfInstaller (13-22)
  • TypePKG (10-10)
  • TypeHomebrew (9-9)
client/internal/updatemanager/installer/types.go (1)
  • Type (3-6)
client/internal/updatemanager/installer/types_darwin.go (2)
client/internal/updatemanager/installer/types.go (1)
  • Type (3-6)
client/internal/updatemanager/installer/types_windows.go (1)
  • TypeOfInstaller (22-40)
client/internal/updatemanager/installer/types_windows.go (3)
client/internal/updatemanager/installer/types.go (1)
  • Type (3-6)
client/internal/updatemanager/installer/types_darwin.go (1)
  • TypeOfInstaller (13-22)
shared/management/status/error.go (1)
  • Errorf (70-75)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Android / Build
  • GitHub Check: iOS / Build
  • GitHub Check: Client / Unit
  • GitHub Check: Build Cache
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: Windows
  • GitHub Check: release_ui
  • GitHub Check: Linux
  • GitHub Check: Darwin
  • GitHub Check: release_ui_darwin
  • GitHub Check: release
  • GitHub Check: Client / Unit
🔇 Additional comments (1)
client/internal/updatemanager/installer/types_windows.go (1)

42-51: Remove this review comment — typeByFileExtension is actively used.

The function is called from client/internal/updatemanager/installer/installer_run_windows.go at line 71. It is not dead code.

Likely an incorrect or invalid review comment.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
client/internal/updatemanager/manager.go (3)

23-27: Consider adding a constant for the update debounce duration.

Line 357 uses 5*time.Minute as a hardcoded duration to prevent rapid successive updates. For consistency and maintainability, consider defining this as a constant here.

 const (
 	latestVersion = "latest"
 	// this version will be ignored
 	developmentVersion = "development"
+	// updateDebounceInterval prevents triggering updates more frequently than this duration
+	updateDebounceInterval = 5 * time.Minute
 )

Then update line 357:

-	if time.Since(m.lastTrigger) < 5*time.Minute {
+	if time.Since(m.lastTrigger) < updateDebounceInterval {

56-57: Update comment to reflect all fields protected by the mutex.

The mutex also protects the updateToLatestVersion field (lines 166, 178, 243), not just update and expectedVersion.

-	// updateMutex protect update and expectedVersion fields
+	// updateMutex protects update, expectedVersion, and updateToLatestVersion fields
 	updateMutex sync.Mutex

169-173: Consider clearing state consistently on parse errors.

When version parsing fails, the method returns early but leaves m.expectedVersion and m.updateToLatestVersion in their previous state. While the invalid version is rejected, this creates an asymmetry with the empty-string case (lines 158-163), which explicitly clears both fields.

For consistency, consider:

 		expectedSemVer, err := v.NewVersion(expectedVersion)
 		if err != nil {
 			log.Errorf("error parsing version: %v", err)
+			m.expectedVersion = nil
+			m.updateToLatestVersion = false
 			return
 		}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a26740b and a7fe837.

📒 Files selected for processing (1)
  • client/internal/updatemanager/manager.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
client/internal/updatemanager/manager.go (8)
client/internal/updatemanager/installer/installer_common.go (1)
  • New (28-32)
client/internal/updatemanager/installer/installer.go (1)
  • New (19-21)
client/internal/updatemanager/update.go (1)
  • UpdateInterface (5-11)
client/internal/updatemanager/installer/types_windows.go (1)
  • TypeOfInstaller (22-40)
client/internal/updatemanager/installer/types_darwin.go (1)
  • TypeOfInstaller (13-22)
shared/management/status/error.go (1)
  • Errorf (70-75)
version/version.go (1)
  • NetbirdVersion (18-20)
client/internal/updatemanager/installer/result.go (1)
  • NewResultHandler (33-42)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (24)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Integration (amd64, sqlite)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: iOS / Build
  • GitHub Check: Android / Build
  • GitHub Check: Linux
  • GitHub Check: release_ui_darwin
  • GitHub Check: Windows
  • GitHub Check: Darwin
  • GitHub Check: release
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
🔇 Additional comments (7)
client/internal/updatemanager/manager.go (7)

62-71: LGTM!

The guard against Homebrew installations on Darwin is appropriate, as noted in the PR objectives that Homebrew updates have different requirements (SIGTERM handling).


126-146: LGTM!

The Start method properly initializes the update watcher, sets up the cancellation context, and launches the update loop goroutine with proper WaitGroup management. The non-blocking channel send pattern (lines 134-137) correctly prevents the callback from blocking.


187-214: LGTM!

The Stop and onContextCancel methods properly coordinate shutdown with appropriate locking and WaitGroup synchronization. The cancellation pattern correctly releases the mutex before waiting for goroutines to complete, avoiding deadlock.


216-231: LGTM!

The update loop correctly uses select to multiplex update signals from management (mgmUpdateChan), version fetcher (updateChannel), and context cancellation. The deferred wg.Done() ensures proper cleanup.


233-309: LGTM!

The update handling logic correctly:

  • Resolves "latest" to an actual version or uses the specific target version
  • Persists the UpdateState before triggering the installation (enabling post-restart success detection)
  • Publishes appropriate events for UI feedback
  • Handles errors from the installation trigger

342-363: LGTM!

The shouldUpdate method correctly implements update guards:

  • Skips development versions
  • Compares semantic versions properly using go-version library
  • Debounces updates to prevent rapid successive attempts

365-374: LGTM!

The helper methods appropriately delegate to the installer package for retrieving error results and triggering installations. Creating new installer instances per call is acceptable for these lightweight operations.

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
management/server/http/handlers/accounts/accounts_handler.go (1)

249-252: Critical issue fixed: return statement now present.

The missing return statement after error handling has been added, preventing the nil pointer dereference issue that was flagged in previous reviews.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 18c8332 and e0567f0.

📒 Files selected for processing (1)
  • management/server/http/handlers/accounts/accounts_handler.go (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
management/server/http/handlers/accounts/accounts_handler.go (4)
shared/management/http/api/types.gen.go (1)
  • PutApiAccountsAccountIdJSONRequestBody (1942-1942)
management/server/types/settings.go (2)
  • Settings (10-58)
  • ExtraSettings (86-103)
management/server/context/auth.go (1)
  • GetUserAuthFromContext (25-30)
shared/management/http/util/util.go (2)
  • WriteError (84-120)
  • WriteErrorResponse (70-80)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (25)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: release_ui
  • GitHub Check: Android / Build
  • GitHub Check: release_ui_darwin
  • GitHub Check: Windows
  • GitHub Check: release
  • GitHub Check: iOS / Build
  • GitHub Check: JS / Lint
  • GitHub Check: Darwin
  • GitHub Check: Linux
  • GitHub Check: Client / Unit
🔇 Additional comments (4)
management/server/http/handlers/accounts/accounts_handler.go (4)

6-6: LGTM! Imports and constants are appropriate.

The new imports and constants support the AutoUpdateVersion validation logic cleanly.

Also applies to: 13-13, 32-34


170-223: LGTM! Good refactoring to extract settings construction.

The new updateAccountRequestSettings helper function cleanly separates the request-to-settings mapping logic from the HTTP handler flow, improving maintainability and testability.


341-341: LGTM! Response serialization is correct.

The AutoUpdateVersion field is properly added to the API response, consistent with the pattern used for other optional settings fields.


212-212: NewSemver usage is correct.

The function parses the given version and returns a new Version that adheres strictly to SemVer specs. The error handling on line 212 properly captures validation failures.

Comment on lines +211 to +220
if req.Settings.AutoUpdateVersion != nil {
_, err := goversion.NewSemver(*req.Settings.AutoUpdateVersion)
if *req.Settings.AutoUpdateVersion == autoUpdateLatestVersion ||
*req.Settings.AutoUpdateVersion == disableAutoUpdate ||
err == nil {
returnSettings.AutoUpdateVersion = *req.Settings.AutoUpdateVersion
} else if *req.Settings.AutoUpdateVersion != "" {
return nil, fmt.Errorf("invalid AutoUpdateVersion")
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use status.Errorf for consistent error handling and improve error message.

The validation logic returns a bare fmt.Errorf at line 218, which will cause util.WriteError to return HTTP 500 instead of the appropriate 422/400 status for validation errors. Other validation errors in this file use status.Errorf(status.InvalidArgument, ...) (see lines 257, 260, 309).

Additionally, the error message "invalid AutoUpdateVersion" is too generic and doesn't inform the user which values are acceptable.

🔎 Apply this diff to fix the error handling:
 	if req.Settings.AutoUpdateVersion != nil {
 		_, err := goversion.NewSemver(*req.Settings.AutoUpdateVersion)
 		if *req.Settings.AutoUpdateVersion == autoUpdateLatestVersion ||
 			*req.Settings.AutoUpdateVersion == disableAutoUpdate ||
 			err == nil {
 			returnSettings.AutoUpdateVersion = *req.Settings.AutoUpdateVersion
 		} else if *req.Settings.AutoUpdateVersion != "" {
-			return nil, fmt.Errorf("invalid AutoUpdateVersion")
+			return nil, status.Errorf(status.InvalidArgument, "invalid AutoUpdateVersion: must be 'latest', 'disabled', or a valid semantic version")
 		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if req.Settings.AutoUpdateVersion != nil {
_, err := goversion.NewSemver(*req.Settings.AutoUpdateVersion)
if *req.Settings.AutoUpdateVersion == autoUpdateLatestVersion ||
*req.Settings.AutoUpdateVersion == disableAutoUpdate ||
err == nil {
returnSettings.AutoUpdateVersion = *req.Settings.AutoUpdateVersion
} else if *req.Settings.AutoUpdateVersion != "" {
return nil, fmt.Errorf("invalid AutoUpdateVersion")
}
}
if req.Settings.AutoUpdateVersion != nil {
_, err := goversion.NewSemver(*req.Settings.AutoUpdateVersion)
if *req.Settings.AutoUpdateVersion == autoUpdateLatestVersion ||
*req.Settings.AutoUpdateVersion == disableAutoUpdate ||
err == nil {
returnSettings.AutoUpdateVersion = *req.Settings.AutoUpdateVersion
} else if *req.Settings.AutoUpdateVersion != "" {
return nil, status.Errorf(status.InvalidArgument, "invalid AutoUpdateVersion: must be 'latest', 'disabled', or a valid semantic version")
}
}
🤖 Prompt for AI Agents
In management/server/http/handlers/accounts/accounts_handler.go around lines 211
to 220, replace the bare fmt.Errorf return for invalid AutoUpdateVersion with a
status.Errorf(status.InvalidArgument, ...) and produce a clearer message;
specifically, validate the value as you already do with goversion.NewSemver and
the special constants (autoUpdateLatestVersion, disableAutoUpdate), and when
invalid return status.Errorf(status.InvalidArgument, "invalid AutoUpdateVersion:
must be empty, '%s', '%s', or a valid semver (e.g. 1.2.3)",
autoUpdateLatestVersion, disableAutoUpdate) — include the rejected value if
useful — so the handler returns the proper 4xx status and a more informative
message.

@pappz pappz merged commit 011cc81 into main Dec 19, 2025
40 of 41 checks passed
@pappz pappz deleted the feat/auto-upgrade-fork branch December 19, 2025 18:57
@coderabbitai coderabbitai Bot mentioned this pull request Jan 30, 2026
7 tasks
@coderabbitai coderabbitai Bot mentioned this pull request Mar 18, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants